import { Subject } from "../Subject"
import { QueryRunner } from "../../query-runner/QueryRunner"
import { ObjectLiteral } from "../../common/ObjectLiteral"
import { CannotAttachTreeChildrenEntityError } from "../../error/CannotAttachTreeChildrenEntityError"
import { DeleteQueryBuilder } from "../../query-builder/DeleteQueryBuilder"
import { OrmUtils } from "../../util/OrmUtils"
import { ColumnMetadata } from "../../metadata/ColumnMetadata"

/**
 * Executes subject operations for closure entities.
 */
export class ClosureSubjectExecutor {
    // -------------------------------------------------------------------------
    // Constructor
    // -------------------------------------------------------------------------

    constructor(protected queryRunner: QueryRunner) {}

    // -------------------------------------------------------------------------
    // Public Methods
    // -------------------------------------------------------------------------

    /**
     * Executes operations when subject is being inserted.
     */
    async insert(subject: Subject): Promise<void> {
        // create values to be inserted into the closure junction
        const closureJunctionInsertMap: ObjectLiteral = {}
        subject.metadata.closureJunctionTable.ancestorColumns.forEach(
            (column) => {
                closureJunctionInsertMap[column.databaseName] =
                    subject.identifier
            },
        )
        subject.metadata.closureJunctionTable.descendantColumns.forEach(
            (column) => {
                closureJunctionInsertMap[column.databaseName] =
                    subject.identifier
            },
        )

        // insert values into the closure junction table
        await this.queryRunner.manager
            .createQueryBuilder()
            .insert()
            .into(subject.metadata.closureJunctionTable.tablePath)
            .values(closureJunctionInsertMap)
            .updateEntity(false)
            .callListeners(false)
            .execute()

        let parent = subject.metadata.treeParentRelation!.getEntityValue(
            subject.entity!,
        ) // if entity was attached via parent
        if (!parent && subject.parentSubject && subject.parentSubject.entity)
            // if entity was attached via children
            parent = subject.parentSubject.insertedValueSet
                ? subject.parentSubject.insertedValueSet
                : subject.parentSubject.entity

        if (parent) {
            const escape = (alias: string) =>
                this.queryRunner.connection.driver.escape(alias)
            const tableName = this.getTableName(
                subject.metadata.closureJunctionTable.tablePath,
            )
            const queryParams: any[] = []

            const ancestorColumnNames =
                subject.metadata.closureJunctionTable.ancestorColumns.map(
                    (column) => {
                        return escape(column.databaseName)
                    },
                )
            const descendantColumnNames =
                subject.metadata.closureJunctionTable.descendantColumns.map(
                    (column) => {
                        return escape(column.databaseName)
                    },
                )
            const childEntityIds1 = subject.metadata.primaryColumns.map(
                (column) => {
                    queryParams.push(
                        column.getEntityValue(
                            subject.insertedValueSet
                                ? subject.insertedValueSet
                                : subject.entity!,
                        ),
                    )
                    return this.queryRunner.connection.driver.createParameter(
                        "child_entity_" + column.databaseName,
                        queryParams.length - 1,
                    )
                },
            )

            const whereCondition =
                subject.metadata.closureJunctionTable.descendantColumns.map(
                    (column) => {
                        const columnName = escape(column.databaseName)
                        const parentId =
                            column.referencedColumn!.getEntityValue(parent)

                        if (!parentId)
                            throw new CannotAttachTreeChildrenEntityError(
                                subject.metadata.name,
                            )

                        queryParams.push(parentId)
                        const parameterName =
                            this.queryRunner.connection.driver.createParameter(
                                "parent_entity_" +
                                    column.referencedColumn!.databaseName,
                                queryParams.length - 1,
                            )
                        return `${columnName} = ${parameterName}`
                    },
                )

            await this.queryRunner.query(
                `INSERT INTO ${tableName} (${[
                    ...ancestorColumnNames,
                    ...descendantColumnNames,
                ].join(", ")}) ` +
                    `SELECT ${ancestorColumnNames.join(
                        ", ",
                    )}, ${childEntityIds1.join(
                        ", ",
                    )} FROM ${tableName} WHERE ${whereCondition.join(" AND ")}`,
                queryParams,
            )
        }
    }

    /**
     * Executes operations when subject is being updated.
     */
    async update(subject: Subject): Promise<void> {
        let parent = subject.metadata.treeParentRelation!.getEntityValue(
            subject.entity!,
        ) // if entity was attached via parent
        if (!parent && subject.parentSubject && subject.parentSubject.entity)
            // if entity was attached via children
            parent = subject.parentSubject.entity

        let entity = subject.databaseEntity // if entity was attached via parent
        if (!entity && parent)
            // if entity was attached via children
            entity = subject.metadata
                .treeChildrenRelation!.getEntityValue(parent)
                .find((child: any) => {
                    return Object.entries(subject.identifier!).every(
                        ([key, value]) => child[key] === value,
                    )
                })

        // Exit if the parent or the entity where never set
        if (entity === undefined || parent === undefined) {
            return
        }

        const oldParent = subject.metadata.treeParentRelation!.getEntityValue(
            entity!,
        )
        const oldParentId = subject.metadata.getEntityIdMap(oldParent)
        const parentId = subject.metadata.getEntityIdMap(parent)

        // Exit if the new and old parents are the same
        if (OrmUtils.compareIds(oldParentId, parentId)) {
            return
        }

        const escape = (alias: string) =>
            this.queryRunner.connection.driver.escape(alias)
        const closureTable = subject.metadata.closureJunctionTable

        const ancestorColumnNames = closureTable.ancestorColumns.map(
            (column) => {
                return escape(column.databaseName)
            },
        )

        const descendantColumnNames = closureTable.descendantColumns.map(
            (column) => {
                return escape(column.databaseName)
            },
        )

        // Delete logic
        const createSubQuery = (qb: DeleteQueryBuilder<any>, alias: string) => {
            const subAlias = `sub${alias}`

            const subSelect = qb
                .createQueryBuilder()
                .select(descendantColumnNames.join(", "))
                .from(closureTable.tablePath, subAlias)

            // Create where conditions e.g. (WHERE "subdescendant"."id_ancestor" = :value_id)
            for (const column of closureTable.ancestorColumns) {
                subSelect.andWhere(
                    `${escape(subAlias)}.${escape(
                        column.databaseName,
                    )} = :value_${column.referencedColumn!.databaseName}`,
                )
            }

            return qb
                .createQueryBuilder()
                .select(descendantColumnNames.join(", "))
                .from(`(${subSelect.getQuery()})`, alias)
                .setParameters(subSelect.getParameters())
                .getQuery()
        }

        const parameters: ObjectLiteral = {}
        for (const column of subject.metadata.primaryColumns) {
            parameters[`value_${column.databaseName}`] =
                entity![column.databaseName]
        }

        await this.queryRunner.manager
            .createQueryBuilder()
            .delete()
            .from(closureTable.tablePath)
            .where(
                (qb) =>
                    `(${descendantColumnNames.join(", ")}) IN (${createSubQuery(
                        qb,
                        "descendant",
                    )})`,
            )
            .andWhere(
                (qb) =>
                    `(${ancestorColumnNames.join(
                        ", ",
                    )}) NOT IN (${createSubQuery(qb, "ancestor")})`,
            )
            .setParameters(parameters)
            .execute()

        /**
         * Only insert new parent if it exits
         *
         * This only happens if the entity doesn't become a root entity
         */
        if (parent) {
            // Insert logic
            const queryParams: any[] = []

            const tableName = this.getTableName(closureTable.tablePath)
            const superAlias = escape("supertree")
            const subAlias = escape("subtree")

            const select = [
                ...ancestorColumnNames.map(
                    (columnName) => `${superAlias}.${columnName}`,
                ),
                ...descendantColumnNames.map(
                    (columnName) => `${subAlias}.${columnName}`,
                ),
            ]

            const entityWhereCondition =
                subject.metadata.closureJunctionTable.ancestorColumns.map(
                    (column) => {
                        const columnName = escape(column.databaseName)
                        const entityId =
                            column.referencedColumn!.getEntityValue(entity!)

                        queryParams.push(entityId)
                        const parameterName =
                            this.queryRunner.connection.driver.createParameter(
                                "entity_" +
                                    column.referencedColumn!.databaseName,
                                queryParams.length - 1,
                            )
                        return `${subAlias}.${columnName} = ${parameterName}`
                    },
                )

            const parentWhereCondition =
                subject.metadata.closureJunctionTable.descendantColumns.map(
                    (column) => {
                        const columnName = escape(column.databaseName)
                        const parentId =
                            column.referencedColumn!.getEntityValue(parent)

                        if (!parentId)
                            throw new CannotAttachTreeChildrenEntityError(
                                subject.metadata.name,
                            )

                        queryParams.push(parentId)
                        const parameterName =
                            this.queryRunner.connection.driver.createParameter(
                                "parent_entity_" +
                                    column.referencedColumn!.databaseName,
                                queryParams.length - 1,
                            )
                        return `${superAlias}.${columnName} = ${parameterName}`
                    },
                )

            await this.queryRunner.query(
                `INSERT INTO ${tableName} (${[
                    ...ancestorColumnNames,
                    ...descendantColumnNames,
                ].join(", ")}) ` +
                    `SELECT ${select.join(", ")} ` +
                    `FROM ${tableName} AS ${superAlias}, ${tableName} AS ${subAlias} ` +
                    `WHERE ${[
                        ...entityWhereCondition,
                        ...parentWhereCondition,
                    ].join(" AND ")}`,
                queryParams,
            )
        }
    }

    /**
     * Executes operations when subject is being removed.
     */
    async remove(subjects: Subject | Subject[]): Promise<void> {
        // Only mssql need to execute deletes for the juntion table as it doesn't support multi cascade paths.
        if (!(this.queryRunner.connection.driver.options.type === "mssql")) {
            return
        }

        if (!Array.isArray(subjects)) subjects = [subjects]

        const escape = (alias: string) =>
            this.queryRunner.connection.driver.escape(alias)
        const identifiers = subjects.map((subject) => subject.identifier)
        const closureTable = subjects[0].metadata.closureJunctionTable

        const generateWheres = (columns: ColumnMetadata[]) => {
            return columns
                .map((column) => {
                    const data = identifiers.map(
                        (identifier) =>
                            identifier![column.referencedColumn!.databaseName],
                    )
                    return `${escape(column.databaseName)} IN (${data.join(
                        ", ",
                    )})`
                })
                .join(" AND ")
        }

        const ancestorWhere = generateWheres(closureTable.ancestorColumns)
        const descendantWhere = generateWheres(closureTable.descendantColumns)

        await this.queryRunner.manager
            .createQueryBuilder()
            .delete()
            .from(closureTable.tablePath)
            .where(ancestorWhere)
            .orWhere(descendantWhere)
            .execute()
    }

    /**
     * Gets escaped table name with schema name if SqlServer or Postgres driver used with custom
     * schema name, otherwise returns escaped table name.
     */
    protected getTableName(tablePath: string): string {
        return tablePath
            .split(".")
            .map((i) => {
                // this condition need because in SQL Server driver when custom database name was specified and schema name was not, we got `dbName..tableName` string, and doesn't need to escape middle empty string
                return i === ""
                    ? i
                    : this.queryRunner.connection.driver.escape(i)
            })
            .join(".")
    }
}
